diff --git a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs index fdda285..26d9814 100644 --- a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs +++ b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs @@ -119,6 +119,48 @@ public static class TraderEndpoints return Results.Ok(Predictalytics.Application.Services.EdgeFreshnessCalculator.Compute(older, recent)); }); + // Smart-money co-movement (#1): wallets that repeatedly enter the same outcomes as this seed, + // around the same time (positive lead = they tend to move first). + group.MapGet("/{id:int}/co-movement", async (int id, int? windowHours, int? minShared, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) => + { + var buy = Predictalytics.Domain.Enums.TradeSide.Buy; + var seed = await db.Trades + .Where(t => t.TraderId == id && t.Side == buy && t.MarketOutcomeId != null) + .OrderByDescending(t => t.ExecutedAt) + .Select(t => new { OutcomeId = t.MarketOutcomeId!.Value, t.ExecutedAt }) + .Take(500) + .ToListAsync(ct); + if (seed.Count == 0) return Results.Ok(Array.Empty()); + + var outcomeIds = seed.Select(s => s.OutcomeId).Distinct().ToList(); + var cands = await db.Trades + .Where(t => t.TraderId != id && t.Side == buy && t.MarketOutcomeId != null && outcomeIds.Contains(t.MarketOutcomeId!.Value)) + .Select(t => new { t.TraderId, OutcomeId = t.MarketOutcomeId!.Value, t.ExecutedAt }) + .ToListAsync(ct); + + var seedEntries = seed.Select(s => new Predictalytics.Application.Services.CoEntry(s.OutcomeId, s.ExecutedAt)).ToList(); + var candEntries = cands.Select(c => new Predictalytics.Application.Services.CandidateEntry(c.TraderId, c.OutcomeId, c.ExecutedAt)).ToList(); + + var ranked = Predictalytics.Application.Services.CoMovementCalculator + .Rank(seedEntries, candEntries, windowHours ?? 48, minShared ?? 3) + .Take(20).ToList(); + + var ids = ranked.Select(r => r.TraderId).ToList(); + var traders = (await db.Traders.Where(t => ids.Contains(t.Id)) + .Select(t => new { t.Id, t.DisplayName, t.Platform }).ToListAsync(ct)) + .ToDictionary(t => t.Id); + + var result = ranked.Select(r => + { + traders.TryGetValue(r.TraderId, out var t); + return new Predictalytics.Application.DTOs.CoMovingWalletDto( + r.TraderId, t?.DisplayName ?? "?", t?.Platform.ToString() ?? "?", + r.SharedMarkets, r.AvgLeadHours, r.AvgLeadHours > 0); + }).ToList(); + + return Results.Ok(result); + }); + group.MapGet("/correlation", async (int traderIdA, int traderIdB, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) => { var positionsA = await db.TraderPositions diff --git a/src/Predictalytics.Api/wwwroot/css/style.css b/src/Predictalytics.Api/wwwroot/css/style.css index 5ccb31f..5c09058 100644 --- a/src/Predictalytics.Api/wwwroot/css/style.css +++ b/src/Predictalytics.Api/wwwroot/css/style.css @@ -1243,3 +1243,35 @@ a:hover { color: #8ab8ff; } font-size: 12px; color: var(--text-muted); } + +/* ─── Co-movement card (trader detail, #1) ─── */ +.comovement-card { + border: 1px solid var(--border); + background: var(--bg-card); + border-radius: var(--radius-sm); + padding: 12px 14px; + margin-bottom: 16px; +} +.comovement-head { + font-weight: 700; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 8px; +} +.comovement-row { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 12px; + padding: 8px 4px; + border-top: 1px solid var(--border); + cursor: pointer; + transition: var(--transition); +} +.comovement-row:hover { background: var(--bg-card-hover); } +.comovement-name { font-weight: 600; font-size: 13px; color: var(--text-primary); } +.comovement-platform { font-size: 11px; color: var(--text-muted); margin-left: 6px; font-weight: 500; } +.comovement-shared { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); white-space: nowrap; } +.comovement-lead { font-size: 12px; font-weight: 700; white-space: nowrap; } +.lead-before { color: var(--success); } +.lead-after { color: var(--text-muted); } diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html index 3a23479..b4a409f 100644 --- a/src/Predictalytics.Api/wwwroot/index.html +++ b/src/Predictalytics.Api/wwwroot/index.html @@ -540,6 +540,7 @@
+
diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js index 18b3315..df5b2ca 100644 --- a/src/Predictalytics.Api/wwwroot/js/app.js +++ b/src/Predictalytics.Api/wwwroot/js/app.js @@ -515,6 +515,27 @@ async function loadTraders() { `).join(''); } +async function loadTraderCoMovement(id) { + const el = document.getElementById('td-comovement'); + if (!el) return; + el.style.display = 'none'; + const wallets = await api(`/api/traders/${id}/co-movement`); + if (!Array.isArray(wallets) || wallets.length === 0) return; + + const rows = wallets.map(w => ` +
+ ${w.displayName}${w.platform} + ${w.sharedMarkets} gemeinsame Märkte + + ${w.movesBeforeSeed ? '↑ ' + Math.abs(w.avgLeadHours).toFixed(0) + 'h früher' : '↓ ' + Math.abs(w.avgLeadHours).toFixed(0) + 'h später'} + +
`).join(''); + el.innerHTML = ` +
🕸 Co-Movement — Wallets, die zeitnah dieselben Wetten eingehen
+
${rows}
`; + el.style.display = 'block'; +} + async function loadTraderEdgeFreshness(id) { const el = document.getElementById('td-edge-freshness'); if (!el) return; @@ -707,6 +728,7 @@ async function viewTrader(id) { } loadTraderDrift(id); loadTraderEdgeFreshness(id); + loadTraderCoMovement(id); document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate); document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d); diff --git a/src/Predictalytics.Application.Tests/Services/CoMovementCalculatorTests.cs b/src/Predictalytics.Application.Tests/Services/CoMovementCalculatorTests.cs new file mode 100644 index 0000000..9d7260d --- /dev/null +++ b/src/Predictalytics.Application.Tests/Services/CoMovementCalculatorTests.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Predictalytics.Application.Services; +using Xunit; + +namespace Predictalytics.Application.Tests.Services; + +public class CoMovementCalculatorTests +{ + private static readonly DateTime T0 = new(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void RanksCoMovers_AndRequiresMinSharedMarkets() + { + // Seed entered outcomes 1,2,3,4. + var seed = new List + { + new(1, T0), new(2, T0.AddDays(1)), new(3, T0.AddDays(2)), new(4, T0.AddDays(3)) + }; + var cands = new List + { + // Trader 10 co-enters 1,2,3 within the window -> qualifies (>=3). + new(10, 1, T0.AddHours(-2)), + new(10, 2, T0.AddDays(1).AddHours(-1)), + new(10, 3, T0.AddDays(2).AddHours(3)), + // Trader 20 co-enters only 1,2 -> below minShared. + new(20, 1, T0.AddHours(1)), + new(20, 2, T0.AddDays(1)), + // Trader 30 enters outcome 3 but far outside the window -> ignored. + new(30, 3, T0.AddDays(10)), + }; + + var ranked = CoMovementCalculator.Rank(seed, cands, windowHours: 48, minSharedMarkets: 3); + + Assert.Single(ranked); + Assert.Equal(10, ranked[0].TraderId); + Assert.Equal(3, ranked[0].SharedMarkets); + } + + [Fact] + public void PositiveLead_MeansCandidateMovedBeforeSeed() + { + var seed = new List { new(1, T0), new(2, T0.AddDays(1)), new(3, T0.AddDays(2)) }; + // Candidate consistently enters 3 hours BEFORE the seed. + var cands = new List + { + new(10, 1, T0.AddHours(-3)), + new(10, 2, T0.AddDays(1).AddHours(-3)), + new(10, 3, T0.AddDays(2).AddHours(-3)), + }; + + var ranked = CoMovementCalculator.Rank(seed, cands); + + Assert.Single(ranked); + Assert.True(ranked[0].AvgLeadHours > 0); + Assert.Equal(3.0, ranked[0].AvgLeadHours, 1); + } + + [Fact] + public void HigherSharedMarketCount_RanksFirst() + { + var seed = Enumerable.Range(1, 6).Select(i => new CoEntry(i, T0.AddHours(i))).ToList(); + var cands = new List(); + // Trader 10 shares 5 markets, trader 20 shares 3. + for (int i = 1; i <= 5; i++) cands.Add(new(10, i, T0.AddHours(i))); + for (int i = 1; i <= 3; i++) cands.Add(new(20, i, T0.AddHours(i))); + + var ranked = CoMovementCalculator.Rank(seed, cands, minSharedMarkets: 3); + + Assert.Equal(2, ranked.Count); + Assert.Equal(10, ranked[0].TraderId); + Assert.Equal(20, ranked[1].TraderId); + } +} diff --git a/src/Predictalytics.Application/DTOs/TraderDto.cs b/src/Predictalytics.Application/DTOs/TraderDto.cs index 173798e..8e80dec 100644 --- a/src/Predictalytics.Application/DTOs/TraderDto.cs +++ b/src/Predictalytics.Application/DTOs/TraderDto.cs @@ -148,6 +148,15 @@ public record TraderCorrelationDto( decimal AgreementRatio ); +/// A wallet that co-moves with a seed trader (smart-money discovery, #1). +public record CoMovingWalletDto( + int TraderId, + string DisplayName, + string Platform, + int SharedMarkets, + double AvgLeadHours, + bool MovesBeforeSeed); + /// One row of the dedicated Insider view (system-level, not a user watchlist). public record InsiderDto( int Id, diff --git a/src/Predictalytics.Application/Services/CoMovementCalculator.cs b/src/Predictalytics.Application/Services/CoMovementCalculator.cs new file mode 100644 index 0000000..aa9481b --- /dev/null +++ b/src/Predictalytics.Application/Services/CoMovementCalculator.cs @@ -0,0 +1,72 @@ +namespace Predictalytics.Application.Services; + +/// One market entry (a Buy) by the seed trader. +public sealed record CoEntry(int OutcomeId, DateTime At); + +/// One market entry (a Buy) by a candidate wallet. +public sealed record CandidateEntry(int TraderId, int OutcomeId, DateTime At); + +/// A wallet that repeatedly enters the same outcomes as the seed, around the same time. +public sealed record CoMovingWallet(int TraderId, int SharedMarkets, double AvgLeadHours); + +/// +/// Smart-money co-movement (#1): given the seed trader's entries and other wallets' entries into the +/// same outcomes, rank the wallets that repeatedly co-enter within a time window. A positive +/// means the wallet tends to move BEFORE the seed — the +/// signal that surfaces informed traders. Pure — no DB access, fully unit-tested. +/// +public static class CoMovementCalculator +{ + public static IReadOnlyList Rank( + IReadOnlyList seedEntries, + IReadOnlyList candidateEntries, + double windowHours = 48, + int minSharedMarkets = 3) + { + // Seed entry times per outcome (a trader may enter the same outcome more than once). + var seedByOutcome = seedEntries + .GroupBy(e => e.OutcomeId) + .ToDictionary(g => g.Key, g => g.Select(e => e.At).OrderBy(t => t).ToList()); + + var window = TimeSpan.FromHours(windowHours); + var perTrader = new Dictionary Markets, double LeadSumHours, int Matches)>(); + + foreach (var cand in candidateEntries) + { + if (!seedByOutcome.TryGetValue(cand.OutcomeId, out var seedTimes)) continue; + + // Nearest seed entry on the same outcome; count only if within the window. + DateTime? nearest = null; + var bestGap = window; + foreach (var st in seedTimes) + { + var gap = (st - cand.At).Duration(); + if (gap <= bestGap) + { + bestGap = gap; + nearest = st; + } + } + if (nearest is null) continue; + + if (!perTrader.TryGetValue(cand.TraderId, out var acc)) + acc = (new HashSet(), 0d, 0); + + acc.Markets.Add(cand.OutcomeId); + // Positive lead = candidate entered before the seed. + acc.LeadSumHours += (nearest.Value - cand.At).TotalHours; + acc.Matches += 1; + perTrader[cand.TraderId] = acc; + } + + return perTrader + .Where(kv => kv.Value.Markets.Count >= minSharedMarkets) + .Select(kv => new CoMovingWallet( + kv.Key, + kv.Value.Markets.Count, + kv.Value.Matches > 0 ? Math.Round(kv.Value.LeadSumHours / kv.Value.Matches, 1) : 0)) + .OrderByDescending(w => w.SharedMarkets) + .ThenByDescending(w => w.AvgLeadHours) + .ToList(); + } +}