Insider-Follow feed: auto-watchlist possible-insider wallets + new-trade alerts

Makes the existing possible_insider trait actionable:
- New AlertType.InsiderActivity (severity 4).
- AlertService.EvaluateInsiderWatchAsync (run from EvaluateAlertsAsync every
  15 min): auto-adds every possible_insider wallet to the watchlist, then fires
  one InsiderActivity alert per new trade one of them places. These wallets
  trade rarely, so a single new trade is the strongest copy signal.
- Dedup + no history spam via WatchlistEntry.LastInsiderAlertAt (migration
  AddWatchlistLastInsiderAlertAt); a freshly auto-added wallet is baselined at
  AddedAt so backfilled trades never alert.
- Repo support: ITraderRepository.GetByTraitAsync, IWatchlistRepository.UpdateAsync.
- UI: distinct 👁 icon for insider alerts (💰 for large positions).
- Tests: auto-add-without-history-alert, alert-on-new-trade-with-dedup.

Migration auto-applies on startup (DependencyInjection.MigrateAsync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-23 19:46:36 +02:00
parent 295752d778
commit 8b9b34342f
12 changed files with 1507 additions and 2 deletions
@@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Predictalytics.Application.Services;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Data.Repositories;
using System;
using System.Linq;
using System.Threading.Tasks;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class AlertServiceTests
{
private static AppDbContext CreateDbContext()
=> new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options);
private static AlertService CreateService(AppDbContext db) => new(
new AlertRepository(db),
new TradeRepository(db, NullLogger<TradeRepository>.Instance),
new TraderRepository(db),
new WatchlistRepository(db),
NullLogger<AlertService>.Instance);
[Fact]
public async Task InsiderWatch_AutoAddsInsider_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.
db.Trades.Add(new Trade
{
Id = 10, TraderId = 1, DbMarketId = 100, Platform = PlatformType.Polymarket,
Side = TradeSide.Buy, Outcome = "Yes", Price = 0.08m, Amount = 900m,
ExecutedAt = DateTime.UtcNow.AddDays(-3)
});
await db.SaveChangesAsync();
var svc = CreateService(db);
await svc.EvaluateInsiderWatchAsync();
Assert.Single(db.WatchlistEntries.Where(w => w.TraderId == 1));
Assert.Empty(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));
}
[Fact]
public async Task InsiderWatch_AlertsOnNewTrade_AndDedupsOnRerun()
{
using var db = CreateDbContext();
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
{
Id = 5, TraderId = 2, Label = "watched", AlertsEnabled = true,
AddedAt = DateTime.UtcNow.AddHours(-1)
});
// A trade placed AFTER the entry was added -> should alert exactly once.
db.Trades.Add(new Trade
{
Id = 20, TraderId = 2, DbMarketId = 200, Platform = PlatformType.Polymarket,
Side = TradeSide.Buy, Outcome = "No", Price = 0.12m, Amount = 1500m,
ExecutedAt = DateTime.UtcNow.AddMinutes(-20)
});
await db.SaveChangesAsync();
var svc = CreateService(db);
await svc.EvaluateInsiderWatchAsync();
var alerts = db.Alerts.Where(a => a.Type == AlertType.InsiderActivity).ToList();
Assert.Single(alerts);
Assert.Equal(4, alerts[0].Severity);
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);
await svc.EvaluateInsiderWatchAsync();
Assert.Single(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));
}
}