Rework insider feed: system-level InsiderWatch + dedicated view (no watchlist writes)

Watchlists will become per-user once the product is offered commercially, so the
system must not auto-add/remove traders there. Decouple insider tracking entirely:

- New system-owned entity InsiderWatch (TraderId unique, FirstDetectedAt,
  LastAlertedTradeAt) + IInsiderWatchRepository; migration AddInsiderWatch.
- AlertService.EvaluateInsiderWatchAsync now maintains InsiderWatch (not the
  watchlist): registers each possible_insider wallet, seeds the high-water mark
  at detection time, and fires one InsiderActivity alert per new trade. Dedup via
  LastAlertedTradeAt.
- Dedicated "Insider" view: GET /api/traders/insiders + InsiderDto + a new
  Insider-Radar page (sorted by market-surprise). Read-only, separate from watchlist.
- Revert the WatchlistEntry.LastInsiderAlertAt field + its migration (unapplied);
  drop the now-unused IWatchlistRepository.UpdateAsync.
- Tests updated to assert InsiderWatch registry (and that no WatchlistEntry is created).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-23 20:52:37 +02:00
parent 8b9b34342f
commit 2bec11d0a9
19 changed files with 342 additions and 82 deletions
@@ -23,18 +23,18 @@ public class AlertServiceTests
new AlertRepository(db),
new TradeRepository(db, NullLogger<TradeRepository>.Instance),
new TraderRepository(db),
new WatchlistRepository(db),
new InsiderWatchRepository(db),
NullLogger<AlertService>.Instance);
[Fact]
public async Task InsiderWatch_AutoAddsInsider_WithoutAlertingOnHistory()
public async Task InsiderWatch_RegistersInsider_WithoutAlertingOnHistory()
{
using var db = CreateDbContext();
var trader = new Trader { Id = 1, PlatformUserId = "0xI", DisplayName = "QuietWhale" };
trader.Traits.Add(new TraderTrait { TraderId = 1, Trait = "possible_insider", Value = 4.2m });
db.Traders.Add(trader);
// A historical trade (predates the auto-add) must NOT produce an alert.
// A historical trade (predates detection) must NOT produce an alert.
db.Trades.Add(new Trade
{
Id = 10, TraderId = 1, DbMarketId = 100, Platform = PlatformType.Polymarket,
@@ -46,7 +46,9 @@ public class AlertServiceTests
var svc = CreateService(db);
await svc.EvaluateInsiderWatchAsync();
Assert.Single(db.WatchlistEntries.Where(w => w.TraderId == 1));
// Registered in the system-level insider registry, NOT the user watchlist.
Assert.Single(db.InsiderWatches.Where(i => i.TraderId == 1));
Assert.Empty(db.WatchlistEntries);
Assert.Empty(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));
}
@@ -58,13 +60,14 @@ public class AlertServiceTests
var trader = new Trader { Id = 2, PlatformUserId = "0xJ", DisplayName = "Insider2" };
trader.Traits.Add(new TraderTrait { TraderId = 2, Trait = "possible_insider", Value = 5m });
db.Traders.Add(trader);
// Already watched, added an hour ago.
db.WatchlistEntries.Add(new WatchlistEntry
// Already registered an hour ago; high-water mark predates the new trade.
db.InsiderWatches.Add(new InsiderWatch
{
Id = 5, TraderId = 2, Label = "watched", AlertsEnabled = true,
AddedAt = DateTime.UtcNow.AddHours(-1)
Id = 3, TraderId = 2,
FirstDetectedAt = DateTime.UtcNow.AddHours(-1),
LastAlertedTradeAt = DateTime.UtcNow.AddHours(-1)
});
// A trade placed AFTER the entry was added -> should alert exactly once.
// A trade placed AFTER the high-water mark -> should alert exactly once.
db.Trades.Add(new Trade
{
Id = 20, TraderId = 2, DbMarketId = 200, Platform = PlatformType.Polymarket,
@@ -82,8 +85,8 @@ public class AlertServiceTests
Assert.Equal(2, alerts[0].TraderId);
// High-water mark advanced; a second run must not re-alert.
var entry = db.WatchlistEntries.First(w => w.Id == 5);
Assert.NotNull(entry.LastInsiderAlertAt);
var watch = db.InsiderWatches.First(i => i.Id == 3);
Assert.True(watch.LastAlertedTradeAt > DateTime.UtcNow.AddMinutes(-25));
await svc.EvaluateInsiderWatchAsync();
Assert.Single(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));