#1 Smart-money co-movement detection (discovery increment 1)

The correlation endpoint was only pairwise + position-overlap based. Add timing-
based, one-to-many co-movement discovery: for a seed trader, find the wallets
that repeatedly enter the SAME outcomes within a time window — surfacing new
informed traders rather than just ranking known ones.

- Pure CoMovementCalculator (Application): ranks candidate wallets by shared
  co-entered markets; positive AvgLeadHours = the wallet tends to move BEFORE the
  seed (the informed-trader signal).
- GET /api/traders/{id}/co-movement?windowHours=48&minShared=3 (bounds the seed
  to its last 500 buys) + CoMovingWalletDto.
- UI: a co-movement card on the trader detail page listing the top related
  wallets with shared-market count and lead/lag (green when they move first).
- Tests: min-shared threshold + window filtering, lead sign, ranking order.

Next increment: cluster the co-movement graph + a visual; weight co-entries that
precede significant price moves. Endpoint is on-demand per seed for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-24 12:27:10 +02:00
parent e5ce69793b
commit 351e938769
7 changed files with 253 additions and 0 deletions
@@ -119,6 +119,48 @@ public static class TraderEndpoints
return Results.Ok(Predictalytics.Application.Services.EdgeFreshnessCalculator.Compute(older, recent)); 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<Predictalytics.Application.DTOs.CoMovingWalletDto>());
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) => group.MapGet("/correlation", async (int traderIdA, int traderIdB, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
{ {
var positionsA = await db.TraderPositions var positionsA = await db.TraderPositions
@@ -1243,3 +1243,35 @@ a:hover { color: #8ab8ff; }
font-size: 12px; font-size: 12px;
color: var(--text-muted); 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); }
@@ -540,6 +540,7 @@
<div class="detail-main"> <div class="detail-main">
<div id="td-drift-banner" class="drift-banner" style="display:none;"></div> <div id="td-drift-banner" class="drift-banner" style="display:none;"></div>
<div id="td-edge-freshness" class="edge-badge-wrap" style="display:none;"></div> <div id="td-edge-freshness" class="edge-badge-wrap" style="display:none;"></div>
<div id="td-comovement" class="comovement-card" style="display:none;"></div>
<div class="tabs-nav"> <div class="tabs-nav">
<button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analyse &amp; KI</button> <button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analyse &amp; KI</button>
<button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Handelshistorie</button> <button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Handelshistorie</button>
+22
View File
@@ -515,6 +515,27 @@ async function loadTraders() {
`).join(''); `).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 => `
<div class="comovement-row" onclick="viewTrader(${w.traderId})">
<span class="comovement-name">${w.displayName}<span class="comovement-platform">${w.platform}</span></span>
<span class="comovement-shared">${w.sharedMarkets} gemeinsame Märkte</span>
<span class="comovement-lead ${w.movesBeforeSeed ? 'lead-before' : 'lead-after'}">
${w.movesBeforeSeed ? '↑ ' + Math.abs(w.avgLeadHours).toFixed(0) + 'h früher' : '↓ ' + Math.abs(w.avgLeadHours).toFixed(0) + 'h später'}
</span>
</div>`).join('');
el.innerHTML = `
<div class="comovement-head">🕸 Co-Movement — Wallets, die zeitnah dieselben Wetten eingehen</div>
<div class="comovement-list">${rows}</div>`;
el.style.display = 'block';
}
async function loadTraderEdgeFreshness(id) { async function loadTraderEdgeFreshness(id) {
const el = document.getElementById('td-edge-freshness'); const el = document.getElementById('td-edge-freshness');
if (!el) return; if (!el) return;
@@ -707,6 +728,7 @@ async function viewTrader(id) {
} }
loadTraderDrift(id); loadTraderDrift(id);
loadTraderEdgeFreshness(id); loadTraderEdgeFreshness(id);
loadTraderCoMovement(id);
document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate); document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate);
document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d); document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d);
@@ -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<CoEntry>
{
new(1, T0), new(2, T0.AddDays(1)), new(3, T0.AddDays(2)), new(4, T0.AddDays(3))
};
var cands = new List<CandidateEntry>
{
// 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<CoEntry> { 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<CandidateEntry>
{
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<CandidateEntry>();
// 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);
}
}
@@ -148,6 +148,15 @@ public record TraderCorrelationDto(
decimal AgreementRatio decimal AgreementRatio
); );
/// <summary>A wallet that co-moves with a seed trader (smart-money discovery, #1).</summary>
public record CoMovingWalletDto(
int TraderId,
string DisplayName,
string Platform,
int SharedMarkets,
double AvgLeadHours,
bool MovesBeforeSeed);
/// <summary>One row of the dedicated Insider view (system-level, not a user watchlist).</summary> /// <summary>One row of the dedicated Insider view (system-level, not a user watchlist).</summary>
public record InsiderDto( public record InsiderDto(
int Id, int Id,
@@ -0,0 +1,72 @@
namespace Predictalytics.Application.Services;
/// <summary>One market entry (a Buy) by the seed trader.</summary>
public sealed record CoEntry(int OutcomeId, DateTime At);
/// <summary>One market entry (a Buy) by a candidate wallet.</summary>
public sealed record CandidateEntry(int TraderId, int OutcomeId, DateTime At);
/// <summary>A wallet that repeatedly enters the same outcomes as the seed, around the same time.</summary>
public sealed record CoMovingWallet(int TraderId, int SharedMarkets, double AvgLeadHours);
/// <summary>
/// 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
/// <see cref="CoMovingWallet.AvgLeadHours"/> means the wallet tends to move BEFORE the seed — the
/// signal that surfaces informed traders. Pure — no DB access, fully unit-tested.
/// </summary>
public static class CoMovementCalculator
{
public static IReadOnlyList<CoMovingWallet> Rank(
IReadOnlyList<CoEntry> seedEntries,
IReadOnlyList<CandidateEntry> 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<int, (HashSet<int> 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<int>(), 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();
}
}