@
#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:
@@ -56,6 +56,7 @@ public static class ApiConfiguration
|
|||||||
routes.MapSearchEndpoints();
|
routes.MapSearchEndpoints();
|
||||||
routes.MapWatchlistEndpoints();
|
routes.MapWatchlistEndpoints();
|
||||||
routes.MapPortfolioEndpoints();
|
routes.MapPortfolioEndpoints();
|
||||||
|
routes.MapCoMovementEndpoints();
|
||||||
routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
|
routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
|
||||||
|
|
||||||
routes.MapGet("/api/capabilities", (IConfiguration config) =>
|
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-stat strong { color: var(--text-primary); }
|
||||||
.portfolio-drop { color: var(--text-muted); }
|
.portfolio-drop { color: var(--text-muted); }
|
||||||
.portfolio-reason { font-size: 12px; 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); }
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
<div class="nav-dot"></div>
|
<div class="nav-dot"></div>
|
||||||
<span>Copy-Portfolio</span>
|
<span>Copy-Portfolio</span>
|
||||||
</a>
|
</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">
|
<a href="#" class="nav-item" data-page="markets">
|
||||||
<div class="nav-dot"></div>
|
<div class="nav-dot"></div>
|
||||||
<span>Märkte</span>
|
<span>Märkte</span>
|
||||||
@@ -389,6 +393,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- 4. Markets List View -->
|
||||||
<section class="page" id="page-markets">
|
<section class="page" id="page-markets">
|
||||||
<div class="page-title-wrap">
|
<div class="page-title-wrap">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
|||||||
if (page === 'watchlist') loadWatchlist();
|
if (page === 'watchlist') loadWatchlist();
|
||||||
if (page === 'insiders') loadInsiders();
|
if (page === 'insiders') loadInsiders();
|
||||||
if (page === 'portfolio') loadPortfolio();
|
if (page === 'portfolio') loadPortfolio();
|
||||||
|
if (page === 'network') loadNetwork();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -571,6 +572,76 @@ async function loadTraderDrift(id) {
|
|||||||
banner.style.display = 'block';
|
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() {
|
async function loadPortfolio() {
|
||||||
const tbody = document.getElementById('portfolioBody');
|
const tbody = document.getElementById('portfolioBody');
|
||||||
const summary = document.getElementById('portfolioSummary');
|
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 DroppedForCorrelation,
|
||||||
int DroppedForCategoryCap);
|
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>
|
/// <summary>A wallet that co-moves with a seed trader (smart-money discovery, #1).</summary>
|
||||||
public record CoMovingWalletDto(
|
public record CoMovingWalletDto(
|
||||||
int TraderId,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user