@
#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:
@@ -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 & KI</button>
|
<button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analyse & 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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user