@
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:
@@ -558,9 +558,10 @@ async function loadAlerts() {
|
||||
const data = await api('/api/alerts?count=50');
|
||||
const el = document.getElementById('alertsList');
|
||||
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 => `
|
||||
<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-title">${a.title}</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 ITradeRepository _tradeRepo;
|
||||
private readonly ITraderRepository _traderRepo;
|
||||
private readonly IWatchlistRepository _watchlistRepo;
|
||||
private readonly ILogger<AlertService> _logger;
|
||||
|
||||
// Alert thresholds (configurable in future)
|
||||
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(
|
||||
IAlertRepository alertRepo,
|
||||
ITradeRepository tradeRepo,
|
||||
ITraderRepository traderRepo,
|
||||
IWatchlistRepository watchlistRepo,
|
||||
ILogger<AlertService> logger)
|
||||
{
|
||||
_alertRepo = alertRepo;
|
||||
_tradeRepo = tradeRepo;
|
||||
_traderRepo = traderRepo;
|
||||
_watchlistRepo = watchlistRepo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -59,6 +65,65 @@ public class AlertService : IAlertService
|
||||
}, 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)
|
||||
|
||||
@@ -22,6 +22,13 @@ public class WatchlistEntry
|
||||
/// <summary>When this entry was added to the watchlist.</summary>
|
||||
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
|
||||
public Trader Trader { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -13,5 +13,7 @@ public enum AlertType
|
||||
/// <summary>Trader exited a position completely</summary>
|
||||
PositionExit = 4,
|
||||
/// <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<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, 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>> GetTopByPnLAsync(int count = 5, DateTime? since = 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<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default);
|
||||
Task AddAsync(WatchlistEntry entry, CancellationToken ct = default);
|
||||
Task UpdateAsync(WatchlistEntry entry, 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)
|
||||
.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)
|
||||
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics)
|
||||
.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)
|
||||
{ _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)
|
||||
{
|
||||
var e = await _db.WatchlistEntries.FindAsync(new object[] { id }, ct);
|
||||
|
||||
+1293
File diff suppressed because it is too large
Load Diff
+29
@@ -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)
|
||||
.HasColumnType("varchar(256)");
|
||||
|
||||
b.Property<DateTime?>("LastInsiderAlertAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user