#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
@@ -56,6 +56,7 @@ public static class ApiConfiguration
routes.MapSearchEndpoints();
routes.MapWatchlistEndpoints();
routes.MapPortfolioEndpoints();
routes.MapCoMovementEndpoints();
routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
routes.MapGet("/api/capabilities", (IConfiguration config) =>
@@ -0,0 +1,128 @@
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.Domain.Enums;
using Predictalytics.Infrastructure.Data;
namespace Predictalytics.Api.Endpoints;
public static class CoMovementEndpoints
{
private const string InsiderTrait = "possible_insider";
private const decimal InformedMovePts = 0.15m; // price rise that counts as a "big move"
private static readonly TimeSpan InformedHorizon = TimeSpan.FromDays(14);
public static void MapCoMovementEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/co-movement").WithTags("CoMovement");
// Smart-money co-movement graph (#1 inc. 2): cluster copy-relevant + insider wallets by shared
// timely co-entries; weight each wallet by how often its entries preceded a big price move.
group.MapGet("/graph", async (double? windowHours, int? minEdge, decimal? minScore,
AppDbContext db, CancellationToken ct) =>
{
var buy = TradeSide.Buy;
var minScoreV = minScore ?? 40m;
var candidates = await db.Traders
.Include(t => t.Analytics)
.Include(t => t.Traits)
.Where(t => !t.IsSuspectedBot && t.Analytics != null
&& (t.Analytics.CopytradingScore >= minScoreV || t.Traits.Any(tr => tr.Trait == InsiderTrait)))
.OrderByDescending(t => t.Analytics!.CopytradingScore)
.Take(80)
.ToListAsync(ct);
if (candidates.Count < 2)
return Results.Ok(new CoMovementGraphDto(System.Array.Empty<CoMovementNodeDto>(), System.Array.Empty<CoMovementEdgeDto>(), 0));
var ids = candidates.Select(t => t.Id).ToList();
var byId = candidates.ToDictionary(t => t.Id);
var raw = await db.Trades
.Where(t => ids.Contains(t.TraderId) && t.Side == buy && t.MarketOutcomeId != null)
.OrderByDescending(t => t.ExecutedAt)
.Select(t => new { t.TraderId, OutcomeId = t.MarketOutcomeId!.Value, t.ExecutedAt })
.Take(12000)
.ToListAsync(ct);
var entries = raw.Select(r => new CandidateEntry(r.TraderId, r.OutcomeId, r.ExecutedAt)).ToList();
var graph = CoMovementGraphBuilder.Build(entries, windowHours ?? 48, minEdge ?? 3);
var nodeIds = graph.ClusterByTrader.Keys.ToHashSet();
var informed = await ComputeInformedShareAsync(db, raw.Where(r => nodeIds.Contains(r.TraderId))
.Select(r => (r.TraderId, r.OutcomeId, r.ExecutedAt)).ToList(), ct);
var nodes = nodeIds.Select(id =>
{
var t = byId[id];
return new CoMovementNodeDto(
id, t.DisplayName, t.Platform.ToString(),
t.Analytics?.CopytradingScore ?? 0,
graph.ClusterByTrader[id],
informed.GetValueOrDefault(id),
t.Traits.Any(tr => tr.Trait == InsiderTrait));
}).ToList();
var edges = graph.Edges.Select(e => new CoMovementEdgeDto(e.TraderA, e.TraderB, e.Weight)).ToList();
var clusterCount = graph.ClusterByTrader.Values.Distinct().Count();
return Results.Ok(new CoMovementGraphDto(nodes, edges, clusterCount));
});
}
/// <summary>
/// Per wallet: fraction of its entries (on outcomes we have stored price history for) that preceded
/// a big favorable price move — the "informed" signal. Uses only stored snapshots; degrades to 0.
/// </summary>
private static async Task<Dictionary<int, double>> ComputeInformedShareAsync(
AppDbContext db, IReadOnlyList<(int TraderId, int OutcomeId, DateTime At)> entries, CancellationToken ct)
{
var result = new Dictionary<int, double>();
var outcomeIds = entries.Select(e => e.OutcomeId).Distinct().ToList();
if (outcomeIds.Count == 0 || outcomeIds.Count > 2000) return result; // guard: keep it bounded
var snaps = await db.MarketOutcomePriceSnapshots
.Where(s => outcomeIds.Contains(s.MarketOutcomeId))
.Select(s => new { s.MarketOutcomeId, s.Price, s.Timestamp })
.ToListAsync(ct);
var byOutcome = snaps
.GroupBy(s => s.MarketOutcomeId)
.ToDictionary(g => g.Key, g => g.OrderBy(x => x.Timestamp).Select(x => (x.Timestamp, x.Price)).ToList());
foreach (var byTrader in entries.GroupBy(e => e.TraderId))
{
int total = 0, informedCount = 0;
foreach (var e in byTrader)
{
if (!byOutcome.TryGetValue(e.OutcomeId, out var series) || series.Count == 0) continue;
var priceAt = PriceAt(series, e.At);
var priceLater = PriceAt(series, e.At + InformedHorizon);
if (priceAt is null || priceLater is null) continue;
total++;
if (priceLater.Value - priceAt.Value >= InformedMovePts) informedCount++;
}
result[byTrader.Key] = total > 0 ? (double)informedCount / total : 0;
}
return result;
// Nearest snapshot price at or before t; falls back to the earliest snapshot.
static decimal? PriceAt(List<(DateTime Ts, decimal Price)> series, DateTime t)
{
decimal? best = null;
foreach (var (ts, price) in series)
{
if (ts <= t) best = price;
else break;
}
return best ?? (series.Count > 0 ? series[0].Price : null);
}
}
}
@@ -1290,3 +1290,13 @@ a:hover { color: #8ab8ff; }
.portfolio-stat strong { color: var(--text-primary); }
.portfolio-drop { color: var(--text-muted); }
.portfolio-reason { font-size: 12px; color: var(--text-muted); }
/* ─── Smart-money network graph (#1 inc. 2) ─── */
.network-graph {
width: 100%;
min-height: 500px;
overflow: hidden;
}
.network-graph svg { display: block; }
.net-node text { pointer-events: none; user-select: none; }
.net-node:hover circle { filter: brightness(1.25); }
+18
View File
@@ -44,6 +44,10 @@
<div class="nav-dot"></div>
<span>Copy-Portfolio</span>
</a>
<a href="#" class="nav-item" data-page="network">
<div class="nav-dot"></div>
<span>Netzwerk</span>
</a>
<a href="#" class="nav-item" data-page="markets">
<div class="nav-dot"></div>
<span>Märkte</span>
@@ -389,6 +393,20 @@
</div>
</section>
<!-- 3d. Smart-Money Network View (#1 inc. 2) -->
<section class="page" id="page-network">
<div class="page-title-wrap">
<div>
<h1 class="page-title">Smart-Money-Netzwerk</h1>
<div class="page-subtitle">Wallets, die wiederholt zeitnah in dieselben Outcomes einsteigen — gruppiert zu Clustern. Knotengröße = Copytrading-Score, grüner Ring = „handelt vor Preisanstiegen" (informiert). Klick öffnet den Trader.</div>
</div>
</div>
<div id="networkSummary" class="portfolio-summary"></div>
<div class="card">
<div id="networkGraph" class="network-graph"></div>
</div>
</section>
<!-- 4. Markets List View -->
<section class="page" id="page-markets">
<div class="page-title-wrap">
+71
View File
@@ -30,6 +30,7 @@ document.querySelectorAll('.nav-item[data-page]').forEach(item => {
if (page === 'watchlist') loadWatchlist();
if (page === 'insiders') loadInsiders();
if (page === 'portfolio') loadPortfolio();
if (page === 'network') loadNetwork();
});
});
@@ -571,6 +572,76 @@ async function loadTraderDrift(id) {
banner.style.display = 'block';
}
const NETWORK_COLORS = ['#1652F0','#12D48A','#7c5cff','#ff9d4c','#F6465D','#5b9dff','#e0b341','#3fc1c9','#c94fd6','#8ab86a'];
async function loadNetwork() {
const host = document.getElementById('networkGraph');
const summary = document.getElementById('networkSummary');
const data = await api('/api/co-movement/graph');
if (!data || !Array.isArray(data.nodes)) {
summary.innerHTML = '';
host.innerHTML = '<div class="empty-state"><p>⚠ Netzwerk konnte nicht geladen werden (siehe Konsole / Server-Log).</p></div>';
return;
}
if (data.nodes.length === 0) {
summary.innerHTML = '';
host.innerHTML = '<div class="empty-state"><p>Noch keine Co-Movement-Verbindungen gefunden (zu wenig überlappende Trades).</p></div>';
return;
}
summary.innerHTML = `
<span class="portfolio-stat"><strong>${data.nodes.length}</strong> Wallets</span>
<span class="portfolio-stat"><strong>${data.edges.length}</strong> Verbindungen</span>
<span class="portfolio-stat"><strong>${data.clusterCount}</strong> Cluster</span>`;
// ── Layout: clusters arranged on a big ring; nodes on a small ring around each cluster centroid ──
const W = 900, H = 600, cx = W / 2, cy = H / 2;
const clusters = [...new Set(data.nodes.map(n => n.cluster))];
const clusterIndex = new Map(clusters.map((c, i) => [c, i]));
const nodesByCluster = new Map(clusters.map(c => [c, data.nodes.filter(n => n.cluster === c)]));
const bigR = Math.min(W, H) * 0.36;
const pos = new Map();
clusters.forEach((c, ci) => {
const members = nodesByCluster.get(c);
const ca = clusters.length > 1 ? (2 * Math.PI * ci) / clusters.length : 0;
const gx = clusters.length > 1 ? cx + bigR * Math.cos(ca) : cx;
const gy = clusters.length > 1 ? cy + bigR * Math.sin(ca) : cy;
const smallR = 20 + members.length * 9;
members.forEach((n, ni) => {
if (members.length === 1) { pos.set(n.traderId, { x: gx, y: gy }); return; }
const a = (2 * Math.PI * ni) / members.length;
pos.set(n.traderId, { x: gx + smallR * Math.cos(a), y: gy + smallR * Math.sin(a) });
});
});
const edgeSvg = data.edges.map(e => {
const a = pos.get(e.source), b = pos.get(e.target);
if (!a || !b) return '';
const op = Math.min(0.5, 0.12 + e.weight * 0.04);
return `<line x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}" stroke="#8B93A7" stroke-opacity="${op.toFixed(2)}" stroke-width="1"/>`;
}).join('');
const nodeSvg = data.nodes.map(n => {
const p = pos.get(n.traderId);
const r = 6 + Number(n.score) / 10;
const color = NETWORK_COLORS[clusterIndex.get(n.cluster) % NETWORK_COLORS.length];
const informed = n.informedShare >= 0.5;
const insiderRing = n.isInsider ? `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${(r + 4).toFixed(1)}" fill="none" stroke="#e0b341" stroke-width="1.5"/>` : '';
const stroke = informed ? '#12D48A' : 'rgba(0,0,0,0.35)';
const sw = informed ? 3 : 1;
const label = (n.displayName || '').slice(0, 12);
return `<g class="net-node" onclick="viewTrader(${n.traderId})" style="cursor:pointer">
<title>${n.displayName} · Score ${Number(n.score).toFixed(0)} · informed ${(n.informedShare*100).toFixed(0)}%${n.isInsider ? ' · Insider' : ''}</title>
${insiderRing}
<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${r.toFixed(1)}" fill="${color}" stroke="${stroke}" stroke-width="${sw}"/>
<text x="${p.x.toFixed(1)}" y="${(p.y + r + 11).toFixed(1)}" text-anchor="middle" font-size="9" fill="#8B93A7">${label}</text>
</g>`;
}).join('');
host.innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="100%" preserveAspectRatio="xMidYMid meet">${edgeSvg}${nodeSvg}</svg>`;
}
async function loadPortfolio() {
const tbody = document.getElementById('portfolioBody');
const summary = document.getElementById('portfolioSummary');
@@ -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());
}
}
@@ -166,6 +166,25 @@ public record CopyPortfolioDto(
int DroppedForCorrelation,
int DroppedForCategoryCap);
/// <summary>One wallet in the co-movement graph (#1 increment 2).</summary>
public record CoMovementNodeDto(
int TraderId,
string DisplayName,
string Platform,
decimal Score,
int Cluster,
double InformedShare,
bool IsInsider);
/// <summary>One undirected co-movement edge (shared timely co-entries).</summary>
public record CoMovementEdgeDto(int Source, int Target, int Weight);
/// <summary>The smart-money co-movement graph: clustered nodes + edges (#1 increment 2).</summary>
public record CoMovementGraphDto(
IReadOnlyList<CoMovementNodeDto> Nodes,
IReadOnlyList<CoMovementEdgeDto> Edges,
int ClusterCount);
/// <summary>A wallet that co-moves with a seed trader (smart-money discovery, #1).</summary>
public record CoMovingWalletDto(
int TraderId,
@@ -0,0 +1,91 @@
namespace Predictalytics.Application.Services;
/// <summary>An undirected co-movement edge: two wallets that co-entered <see cref="Weight"/> shared outcomes.</summary>
public sealed record CoMovementEdge(int TraderA, int TraderB, int Weight);
/// <summary>The co-movement graph: edges plus the cluster each connected wallet belongs to.</summary>
public sealed record CoMovementGraph(
IReadOnlyList<CoMovementEdge> Edges,
IReadOnlyDictionary<int, int> ClusterByTrader);
/// <summary>
/// Builds the smart-money co-movement graph (#1, increment 2): wallets are linked when they repeatedly
/// enter the SAME outcomes within a time window, and the connected components of that graph are the
/// "smart-money clusters". Pure — no DB access — and fully unit-tested. Clustering is union-find over
/// edges that meet the minimum weight.
/// </summary>
public static class CoMovementGraphBuilder
{
public static CoMovementGraph Build(
IReadOnlyList<CandidateEntry> entries,
double windowHours = 48,
int minEdgeWeight = 3)
{
var window = TimeSpan.FromHours(windowHours);
// Accumulate, per unordered wallet pair, the DISTINCT outcomes they co-entered within the window.
var pairOutcomes = new Dictionary<(int, int), HashSet<int>>();
foreach (var group in entries.GroupBy(e => e.OutcomeId))
{
var list = group.ToList();
for (int i = 0; i < list.Count; i++)
{
for (int j = i + 1; j < list.Count; j++)
{
if (list[i].TraderId == list[j].TraderId) continue;
if ((list[i].At - list[j].At).Duration() > window) continue;
var a = Math.Min(list[i].TraderId, list[j].TraderId);
var b = Math.Max(list[i].TraderId, list[j].TraderId);
if (!pairOutcomes.TryGetValue((a, b), out var set))
{
set = new HashSet<int>();
pairOutcomes[(a, b)] = set;
}
set.Add(group.Key);
}
}
}
var edges = pairOutcomes
.Where(kv => kv.Value.Count >= minEdgeWeight)
.Select(kv => new CoMovementEdge(kv.Key.Item1, kv.Key.Item2, kv.Value.Count))
.OrderByDescending(e => e.Weight)
.ToList();
var clusterByTrader = ClusterConnectedComponents(edges);
return new CoMovementGraph(edges, clusterByTrader);
}
/// <summary>Union-find: assign each wallet in an edge a cluster id (the smallest trader id in its component).</summary>
private static Dictionary<int, int> ClusterConnectedComponents(IReadOnlyList<CoMovementEdge> edges)
{
var parent = new Dictionary<int, int>();
int Find(int x)
{
parent.TryAdd(x, x);
while (parent[x] != x)
{
parent[x] = parent[parent[x]]; // path halving
x = parent[x];
}
return x;
}
void Union(int a, int b)
{
var ra = Find(a);
var rb = Find(b);
if (ra == rb) return;
// Keep the smaller id as the root so cluster ids are stable/deterministic.
if (ra < rb) parent[rb] = ra; else parent[ra] = rb;
}
foreach (var e in edges)
Union(e.TraderA, e.TraderB);
return parent.Keys.ToDictionary(t => t, Find);
}
}