From be56eadcc58f924a2a2ae47ec6293a998158e9f1 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 3 Aug 2026 23:06:36 +0200 Subject: [PATCH] @ #1 increment 2: cluster the co-movement graph + network visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 @ --- src/Predictalytics.Api/ApiConfiguration.cs | 1 + .../Endpoints/CoMovementEndpoints.cs | 128 ++++++++++++++++++ src/Predictalytics.Api/wwwroot/css/style.css | 10 ++ src/Predictalytics.Api/wwwroot/index.html | 18 +++ src/Predictalytics.Api/wwwroot/js/app.js | 71 ++++++++++ .../Services/CoMovementGraphBuilderTests.cs | 70 ++++++++++ .../DTOs/TraderDto.cs | 19 +++ .../Services/CoMovementGraphBuilder.cs | 91 +++++++++++++ 8 files changed, 408 insertions(+) create mode 100644 src/Predictalytics.Api/Endpoints/CoMovementEndpoints.cs create mode 100644 src/Predictalytics.Application.Tests/Services/CoMovementGraphBuilderTests.cs create mode 100644 src/Predictalytics.Application/Services/CoMovementGraphBuilder.cs diff --git a/src/Predictalytics.Api/ApiConfiguration.cs b/src/Predictalytics.Api/ApiConfiguration.cs index ff31107..39a6fb1 100644 --- a/src/Predictalytics.Api/ApiConfiguration.cs +++ b/src/Predictalytics.Api/ApiConfiguration.cs @@ -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) => diff --git a/src/Predictalytics.Api/Endpoints/CoMovementEndpoints.cs b/src/Predictalytics.Api/Endpoints/CoMovementEndpoints.cs new file mode 100644 index 0000000..f36034c --- /dev/null +++ b/src/Predictalytics.Api/Endpoints/CoMovementEndpoints.cs @@ -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(), System.Array.Empty(), 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)); + }); + } + + /// + /// 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. + /// + private static async Task> ComputeInformedShareAsync( + AppDbContext db, IReadOnlyList<(int TraderId, int OutcomeId, DateTime At)> entries, CancellationToken ct) + { + var result = new Dictionary(); + 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); + } + } +} diff --git a/src/Predictalytics.Api/wwwroot/css/style.css b/src/Predictalytics.Api/wwwroot/css/style.css index c79b986..94e2fb3 100644 --- a/src/Predictalytics.Api/wwwroot/css/style.css +++ b/src/Predictalytics.Api/wwwroot/css/style.css @@ -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); } diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html index 765f4e4..ea8cf99 100644 --- a/src/Predictalytics.Api/wwwroot/index.html +++ b/src/Predictalytics.Api/wwwroot/index.html @@ -44,6 +44,10 @@ Copy-Portfolio + + + Netzwerk + Märkte @@ -389,6 +393,20 @@ + +
+
+
+

Smart-Money-Netzwerk

+
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.
+
+
+
+
+
+
+
+
diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js index 0c32d1f..6908299 100644 --- a/src/Predictalytics.Api/wwwroot/js/app.js +++ b/src/Predictalytics.Api/wwwroot/js/app.js @@ -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 = '

⚠ Netzwerk konnte nicht geladen werden (siehe Konsole / Server-Log).

'; + return; + } + if (data.nodes.length === 0) { + summary.innerHTML = ''; + host.innerHTML = '

Noch keine Co-Movement-Verbindungen gefunden (zu wenig überlappende Trades).

'; + return; + } + + summary.innerHTML = ` + ${data.nodes.length} Wallets + ${data.edges.length} Verbindungen + ${data.clusterCount} Cluster`; + + // ── 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 ``; + }).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 ? `` : ''; + const stroke = informed ? '#12D48A' : 'rgba(0,0,0,0.35)'; + const sw = informed ? 3 : 1; + const label = (n.displayName || '').slice(0, 12); + return ` + ${n.displayName} · Score ${Number(n.score).toFixed(0)} · informed ${(n.informedShare*100).toFixed(0)}%${n.isInsider ? ' · Insider' : ''} + ${insiderRing} + + ${label} + `; + }).join(''); + + host.innerHTML = `${edgeSvg}${nodeSvg}`; +} + async function loadPortfolio() { const tbody = document.getElementById('portfolioBody'); const summary = document.getElementById('portfolioSummary'); diff --git a/src/Predictalytics.Application.Tests/Services/CoMovementGraphBuilderTests.cs b/src/Predictalytics.Application.Tests/Services/CoMovementGraphBuilderTests.cs new file mode 100644 index 0000000..42977da --- /dev/null +++ b/src/Predictalytics.Application.Tests/Services/CoMovementGraphBuilderTests.cs @@ -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 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 + { + 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()); + } +} diff --git a/src/Predictalytics.Application/DTOs/TraderDto.cs b/src/Predictalytics.Application/DTOs/TraderDto.cs index c98b1c6..a10e0ea 100644 --- a/src/Predictalytics.Application/DTOs/TraderDto.cs +++ b/src/Predictalytics.Application/DTOs/TraderDto.cs @@ -166,6 +166,25 @@ public record CopyPortfolioDto( int DroppedForCorrelation, int DroppedForCategoryCap); +/// One wallet in the co-movement graph (#1 increment 2). +public record CoMovementNodeDto( + int TraderId, + string DisplayName, + string Platform, + decimal Score, + int Cluster, + double InformedShare, + bool IsInsider); + +/// One undirected co-movement edge (shared timely co-entries). +public record CoMovementEdgeDto(int Source, int Target, int Weight); + +/// The smart-money co-movement graph: clustered nodes + edges (#1 increment 2). +public record CoMovementGraphDto( + IReadOnlyList Nodes, + IReadOnlyList Edges, + int ClusterCount); + /// A wallet that co-moves with a seed trader (smart-money discovery, #1). public record CoMovingWalletDto( int TraderId, diff --git a/src/Predictalytics.Application/Services/CoMovementGraphBuilder.cs b/src/Predictalytics.Application/Services/CoMovementGraphBuilder.cs new file mode 100644 index 0000000..34445d0 --- /dev/null +++ b/src/Predictalytics.Application/Services/CoMovementGraphBuilder.cs @@ -0,0 +1,91 @@ +namespace Predictalytics.Application.Services; + +/// An undirected co-movement edge: two wallets that co-entered shared outcomes. +public sealed record CoMovementEdge(int TraderA, int TraderB, int Weight); + +/// The co-movement graph: edges plus the cluster each connected wallet belongs to. +public sealed record CoMovementGraph( + IReadOnlyList Edges, + IReadOnlyDictionary ClusterByTrader); + +/// +/// 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. +/// +public static class CoMovementGraphBuilder +{ + public static CoMovementGraph Build( + IReadOnlyList 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>(); + + 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(); + 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); + } + + /// Union-find: assign each wallet in an edge a cluster id (the smallest trader id in its component). + private static Dictionary ClusterConnectedComponents(IReadOnlyList edges) + { + var parent = new Dictionary(); + + 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); + } +}