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
+2 -1
View File
@@ -558,9 +558,10 @@ async function loadAlerts() {
const data = await api('/api/alerts?count=50'); const data = await api('/api/alerts?count=50');
const el = document.getElementById('alertsList'); const el = document.getElementById('alertsList');
if (!data || !data.length) { el.innerHTML = '<div class="empty-state"><p>No alerts yet.</p></div>'; return; } if (!data || !data.length) { el.innerHTML = '<div class="empty-state"><p>No alerts yet.</p></div>'; return; }
const alertIcon = t => t === 'InsiderActivity' ? '👁' : t === 'LargePosition' ? '💰' : '🔔';
el.innerHTML = data.map(a => ` el.innerHTML = data.map(a => `
<div class="alert-item ${a.isRead ? '' : 'alert-unread'}"> <div class="alert-item ${a.isRead ? '' : 'alert-unread'}">
<div class="alert-icon alert-severity-${a.severity}">🔔</div> <div class="alert-icon alert-severity-${a.severity}">${alertIcon(a.type)}</div>
<div class="alert-content"> <div class="alert-content">
<div class="alert-title">${a.title}</div> <div class="alert-title">${a.title}</div>
<div class="alert-message">${a.message}</div> <div class="alert-message">${a.message}</div>
@@ -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));
}
}
@@ -15,20 +15,26 @@ public class AlertService : IAlertService
private readonly IAlertRepository _alertRepo; private readonly IAlertRepository _alertRepo;
private readonly ITradeRepository _tradeRepo; private readonly ITradeRepository _tradeRepo;
private readonly ITraderRepository _traderRepo; private readonly ITraderRepository _traderRepo;
private readonly IWatchlistRepository _watchlistRepo;
private readonly ILogger<AlertService> _logger; private readonly ILogger<AlertService> _logger;
// Alert thresholds (configurable in future) // Alert thresholds (configurable in future)
private const decimal LargePositionThresholdUsd = 5000m; private const decimal LargePositionThresholdUsd = 5000m;
/// <summary>Trait computed by <c>TraderTraitCalculator</c> for statistically improbable longshot winners.</summary>
private const string PossibleInsiderTrait = "possible_insider";
public AlertService( public AlertService(
IAlertRepository alertRepo, IAlertRepository alertRepo,
ITradeRepository tradeRepo, ITradeRepository tradeRepo,
ITraderRepository traderRepo, ITraderRepository traderRepo,
IWatchlistRepository watchlistRepo,
ILogger<AlertService> logger) ILogger<AlertService> logger)
{ {
_alertRepo = alertRepo; _alertRepo = alertRepo;
_tradeRepo = tradeRepo; _tradeRepo = tradeRepo;
_traderRepo = traderRepo; _traderRepo = traderRepo;
_watchlistRepo = watchlistRepo;
_logger = logger; _logger = logger;
} }
@@ -59,6 +65,65 @@ public class AlertService : IAlertService
}, ct); }, ct);
} }
} }
await EvaluateInsiderWatchAsync(ct);
}
/// <summary>
/// Insider-Follow feed: keeps every <c>possible_insider</c> wallet on the watchlist and fires a
/// high-severity alert for each new trade one of them places. These wallets trade rarely, so a
/// single new trade is the strongest copy signal we have. Dedup is via
/// <see cref="WatchlistEntry.LastInsiderAlertAt"/>; a freshly auto-added wallet is baselined at
/// its <see cref="WatchlistEntry.AddedAt"/> so historical trades never trigger a backlog of alerts.
/// </summary>
public async Task EvaluateInsiderWatchAsync(CancellationToken ct = default)
{
var insiders = await _traderRepo.GetByTraitAsync(PossibleInsiderTrait, ct);
foreach (var trader in insiders)
{
var entry = trader.WatchlistEntries.FirstOrDefault();
// Auto-add newly detected insiders; baseline at now so we don't alert on their history.
if (entry == null)
{
await _watchlistRepo.AddAsync(new WatchlistEntry
{
TraderId = trader.Id,
Label = "Auto: Possible Insider",
Notes = "Automatisch aufgenommen (possible_insider-Trait).",
AlertsEnabled = true
}, ct);
_logger.LogInformation("👁 Insider-Watch: auto-added {Trader} to watchlist", trader.DisplayName);
continue;
}
if (!entry.AlertsEnabled) continue;
var since = entry.LastInsiderAlertAt ?? entry.AddedAt;
var recent = await _tradeRepo.GetByTraderIdAsync(trader.Id, 0, 50, ct);
var newTrades = recent.Where(t => t.ExecutedAt > since).OrderBy(t => t.ExecutedAt).ToList();
if (newTrades.Count == 0) continue;
foreach (var trade in newTrades)
{
var marketRef = trade.DbMarketId.HasValue
? $"Market #{trade.DbMarketId}"
: (!string.IsNullOrEmpty(trade.MarketId) ? $"Market {trade.MarketId[..Math.Min(12, trade.MarketId.Length)]}..." : "Unknown Market");
await CreateAlertAsync(new Alert
{
Type = AlertType.InsiderActivity,
Platform = trade.Platform,
TraderId = trader.Id,
Title = $"Insider-Wallet aktiv: {trade.Side}",
Message = $"{trader.DisplayName} {trade.Side} ${trade.Amount:N0} auf {marketRef} ({trade.Outcome} @ {trade.Price:P0})",
Severity = 4
}, ct);
}
entry.LastInsiderAlertAt = newTrades.Max(t => t.ExecutedAt);
await _watchlistRepo.UpdateAsync(entry, ct);
}
} }
public async Task CreateAlertAsync(Alert alert, CancellationToken ct = default) public async Task CreateAlertAsync(Alert alert, CancellationToken ct = default)
@@ -22,6 +22,13 @@ public class WatchlistEntry
/// <summary>When this entry was added to the watchlist.</summary> /// <summary>When this entry was added to the watchlist.</summary>
public DateTime AddedAt { get; set; } = DateTime.UtcNow; public DateTime AddedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// High-water mark for insider-activity alerts: the ExecutedAt of the newest trade already
/// alerted on. Null until the first insider alert fires; new-trade detection uses
/// <see cref="AddedAt"/> as the baseline so we never alert on backfilled history.
/// </summary>
public DateTime? LastInsiderAlertAt { get; set; }
// Navigation // Navigation
public Trader Trader { get; set; } = null!; public Trader Trader { get; set; } = null!;
} }
+3 -1
View File
@@ -13,5 +13,7 @@ public enum AlertType
/// <summary>Trader exited a position completely</summary> /// <summary>Trader exited a position completely</summary>
PositionExit = 4, PositionExit = 4,
/// <summary>Custom user-defined alert</summary> /// <summary>Custom user-defined alert</summary>
Custom = 5 Custom = 5,
/// <summary>A watched possible-insider wallet placed a new trade (rare, high-signal).</summary>
InsiderActivity = 6
} }
@@ -9,6 +9,9 @@ public interface ITraderRepository
Task<Trader?> GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default); Task<Trader?> GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default);
Task<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default); Task<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default);
Task<IReadOnlyList<Trader>> GetWatchlistedAsync(CancellationToken ct = default); Task<IReadOnlyList<Trader>> GetWatchlistedAsync(CancellationToken ct = default);
/// <summary>Traders that carry a given trait, with their Traits and WatchlistEntries loaded.</summary>
Task<IReadOnlyList<Trader>> GetByTraitAsync(string trait, CancellationToken ct = default);
Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default); Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default);
Task<IReadOnlyList<Trader>> GetTopByPnLAsync(int count = 5, DateTime? since = null, CancellationToken ct = default); Task<IReadOnlyList<Trader>> GetTopByPnLAsync(int count = 5, DateTime? since = null, CancellationToken ct = default);
Task<int> GetCountAsync(PlatformType? platform = null, CancellationToken ct = default); Task<int> GetCountAsync(PlatformType? platform = null, CancellationToken ct = default);
@@ -7,5 +7,6 @@ public interface IWatchlistRepository
Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default); Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default);
Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default); Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default);
Task AddAsync(WatchlistEntry entry, CancellationToken ct = default); Task AddAsync(WatchlistEntry entry, CancellationToken ct = default);
Task UpdateAsync(WatchlistEntry entry, CancellationToken ct = default);
Task RemoveAsync(int id, CancellationToken ct = default); Task RemoveAsync(int id, CancellationToken ct = default);
} }
@@ -45,6 +45,13 @@ public class TraderRepository : ITraderRepository
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.WatchlistEntries) => await _db.Traders.Include(t => t.CurrentScore).Include(t => t.WatchlistEntries)
.Where(t => t.WatchlistEntries.Any()).ToListAsync(ct); .Where(t => t.WatchlistEntries.Any()).ToListAsync(ct);
public async Task<IReadOnlyList<Trader>> GetByTraitAsync(string trait, CancellationToken ct = default)
=> await _db.Traders
.Include(t => t.Traits)
.Include(t => t.WatchlistEntries)
.Where(t => t.Traits.Any(tr => tr.Trait == trait))
.ToListAsync(ct);
public async Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default) public async Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default)
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics) => await _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics)
.OrderByDescending(t => t.CurrentScore!.CombinedScore).Take(count).ToListAsync(ct); .OrderByDescending(t => t.CurrentScore!.CombinedScore).Take(count).ToListAsync(ct);
@@ -21,6 +21,9 @@ public class WatchlistRepository : IWatchlistRepository
public async Task AddAsync(WatchlistEntry entry, CancellationToken ct = default) public async Task AddAsync(WatchlistEntry entry, CancellationToken ct = default)
{ _db.WatchlistEntries.Add(entry); await _db.SaveChangesAsync(ct); } { _db.WatchlistEntries.Add(entry); await _db.SaveChangesAsync(ct); }
public async Task UpdateAsync(WatchlistEntry entry, CancellationToken ct = default)
{ _db.WatchlistEntries.Update(entry); await _db.SaveChangesAsync(ct); }
public async Task RemoveAsync(int id, CancellationToken ct = default) public async Task RemoveAsync(int id, CancellationToken ct = default)
{ {
var e = await _db.WatchlistEntries.FindAsync(new object[] { id }, ct); var e = await _db.WatchlistEntries.FindAsync(new object[] { id }, ct);
@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddWatchlistLastInsiderAlertAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "LastInsiderAlertAt",
table: "WatchlistEntries",
type: "datetime(6)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastInsiderAlertAt",
table: "WatchlistEntries");
}
}
}
@@ -1038,6 +1038,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("varchar(256)"); .HasColumnType("varchar(256)");
b.Property<DateTime?>("LastInsiderAlertAt")
.HasColumnType("datetime(6)");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("longtext"); .HasColumnType("longtext");