#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
@@ -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);
}
}